<!DOCTYPE html>
<!--
@license
Copyright (C) 2015 The Android Open Source Project

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
<title>gr-file-list</title>

<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>

<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
<script src="/bower_components/web-component-tester/browser.js"></script>
<link rel="import" href="../../../test/common-test-setup.html"/>
<script src="/bower_components/page/page.js"></script>
<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
<script src="../../../scripts/util.js"></script>

<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
<link rel="import" href="gr-file-list.html">

<script>void(0);</script>

<dom-module id="comment-api-mock">
  <template>
    <gr-file-list id="fileList"
        change-comments="[[_changeComments]]"
        on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
    <gr-comment-api id="commentAPI"></gr-comment-api>
  </template>
  <script src="../../diff/gr-comment-api/gr-comment-api-mock.js"></script>
</dom-module>

<test-fixture id="basic">
  <template>
    <comment-api-mock></comment-api-mock>
  </template>
</test-fixture>

<script>
  suite('gr-file-list tests', () => {
    const kb = window.Gerrit.KeyboardShortcutBinder;
    kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
    kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
    kb.bindShortcut(kb.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
    kb.bindShortcut(kb.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
    kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
    kb.bindShortcut(kb.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
    kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
    kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
    kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
    kb.bindShortcut(kb.Shortcut.OPEN_LAST_FILE, '[');
    kb.bindShortcut(kb.Shortcut.OPEN_FIRST_FILE, ']');
    kb.bindShortcut(kb.Shortcut.OPEN_FILE, 'o');
    kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
    kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
    kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
    kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');

    let element;
    let commentApiWrapper;
    let sandbox;
    let saveStub;
    let loadCommentSpy;

    setup(done => {
      sandbox = sinon.sandbox.create();
      stub('gr-rest-api-interface', {
        getLoggedIn() { return Promise.resolve(true); },
        getPreferences() { return Promise.resolve({}); },
        getDiffPreferences() { return Promise.resolve({}); },
        getDiffComments() { return Promise.resolve({}); },
        getDiffRobotComments() { return Promise.resolve({}); },
        getDiffDrafts() { return Promise.resolve({}); },
        getAccountCapabilities() { return Promise.resolve({}); },
      });
      stub('gr-date-formatter', {
        _loadTimeFormat() { return Promise.resolve(''); },
      });
      stub('gr-diff-host', {
        reload() { return Promise.resolve(); },
      });

      // Element must be wrapped in an element with direct access to the
      // comment API.
      commentApiWrapper = fixture('basic');
      element = commentApiWrapper.$.fileList;
      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');

      // Stub methods on the changeComments object after changeComments has
      // been initialized.
      commentApiWrapper.loadComments().then(() => {
        sandbox.stub(element.changeComments, 'getPaths').returns({});
        sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
            .returns({meta: {}, left: [], right: []});
        done();
      });
      element._loading = false;
      element.diffPrefs = {};
      element.numFilesShown = 200;
      element.patchRange = {
        basePatchNum: 'PARENT',
        patchNum: '2',
      };
      saveStub = sandbox.stub(element, '_saveReviewedState',
          () => { return Promise.resolve(); });
    });

    teardown(() => {
      sandbox.restore();
    });

    test('correct number of files are shown', () => {
      element.fileListIncrement = 300;
      element._filesByPath = _.range(500)
          .reduce((_filesByPath, i) => {
            _filesByPath['/file' + i] = {lines_inserted: 9};
            return _filesByPath;
          }, {});

      flushAsynchronousOperations();
      assert.equal(
          Polymer.dom(element.root).querySelectorAll('.file-row').length,
          element.numFilesShown);
      const controlRow = element.$$('.controlRow');
      assert.isFalse(controlRow.classList.contains('invisible'));
      assert.equal(element.$.incrementButton.textContent.trim(),
          'Show 300 more');
      assert.equal(element.$.showAllButton.textContent.trim(),
          'Show all 500 files');

      MockInteractions.tap(element.$.showAllButton);
      flushAsynchronousOperations();

      assert.equal(element.numFilesShown, 500);
      assert.equal(element._shownFiles.length, 500);
      assert.isTrue(controlRow.classList.contains('invisible'));
    });

    test('rendering each row calls the _reportRenderedRow method', () => {
      const renderedStub = sandbox.stub(element, '_reportRenderedRow');
      element._filesByPath = _.range(10)
          .reduce((_filesByPath, i) => {
            _filesByPath['/file' + i] = {lines_inserted: 9};
            return _filesByPath;
          }, {});
      flushAsynchronousOperations();
      assert.equal(
          Polymer.dom(element.root).querySelectorAll('.file-row').length, 10);
      assert.equal(renderedStub.callCount, 10);
    });

    test('calculate totals for patch number', () => {
      element._filesByPath = {
        '/COMMIT_MSG': {
          lines_inserted: 9,
        },
        '/MERGE_LIST': {
          lines_inserted: 9,
        },
        'file_added_in_rev2.txt': {
          lines_inserted: 1,
          lines_deleted: 1,
          size_delta: 10,
          size: 100,
        },
        'myfile.txt': {
          lines_inserted: 1,
          lines_deleted: 1,
          size_delta: 10,
          size: 100,
        },
      };

      assert.deepEqual(element._patchChange, {
        inserted: 2,
        deleted: 2,
        size_delta_inserted: 0,
        size_delta_deleted: 0,
        total_size: 0,
      });
      assert.isTrue(element._hideBinaryChangeTotals);
      assert.isFalse(element._hideChangeTotals);

      // Test with a commit message that isn't the first file.
      element._filesByPath = {
        'file_added_in_rev2.txt': {
          lines_inserted: 1,
          lines_deleted: 1,
        },
        '/COMMIT_MSG': {
          lines_inserted: 9,
        },
        '/MERGE_LIST': {
          lines_inserted: 9,
        },
        'myfile.txt': {
          lines_inserted: 1,
          lines_deleted: 1,
        },
      };

      assert.deepEqual(element._patchChange, {
        inserted: 2,
        deleted: 2,
        size_delta_inserted: 0,
        size_delta_deleted: 0,
        total_size: 0,
      });
      assert.isTrue(element._hideBinaryChangeTotals);
      assert.isFalse(element._hideChangeTotals);

      // Test with no commit message.
      element._filesByPath = {
        'file_added_in_rev2.txt': {
          lines_inserted: 1,
          lines_deleted: 1,
        },
        'myfile.txt': {
          lines_inserted: 1,
          lines_deleted: 1,
        },
      };

      assert.deepEqual(element._patchChange, {
        inserted: 2,
        deleted: 2,
        size_delta_inserted: 0,
        size_delta_deleted: 0,
        total_size: 0,
      });
      assert.isTrue(element._hideBinaryChangeTotals);
      assert.isFalse(element._hideChangeTotals);

      // Test with files missing either lines_inserted or lines_deleted.
      element._filesByPath = {
        'file_added_in_rev2.txt': {lines_inserted: 1},
        'myfile.txt': {lines_deleted: 1},
      };
      assert.deepEqual(element._patchChange, {
        inserted: 1,
        deleted: 1,
        size_delta_inserted: 0,
        size_delta_deleted: 0,
        total_size: 0,
      });
      assert.isTrue(element._hideBinaryChangeTotals);
      assert.isFalse(element._hideChangeTotals);
    });

    test('binary only files', () => {
      element._filesByPath = {
        '/COMMIT_MSG': {lines_inserted: 9},
        'file_binary_1': {binary: true, size_delta: 10, size: 100},
        'file_binary_2': {binary: true, size_delta: -5, size: 120},
      };
      assert.deepEqual(element._patchChange, {
        inserted: 0,
        deleted: 0,
        size_delta_inserted: 10,
        size_delta_deleted: -5,
        total_size: 220,
      });
      assert.isFalse(element._hideBinaryChangeTotals);
      assert.isTrue(element._hideChangeTotals);
    });

    test('binary and regular files', () => {
      element._filesByPath = {
        '/COMMIT_MSG': {lines_inserted: 9},
        'file_binary_1': {binary: true, size_delta: 10, size: 100},
        'file_binary_2': {binary: true, size_delta: -5, size: 120},
        'myfile.txt': {lines_deleted: 5, size_delta: -10, size: 100},
        'myfile2.txt': {lines_inserted: 10},
      };
      assert.deepEqual(element._patchChange, {
        inserted: 10,
        deleted: 5,
        size_delta_inserted: 10,
        size_delta_deleted: -5,
        total_size: 220,
      });
      assert.isFalse(element._hideBinaryChangeTotals);
      assert.isFalse(element._hideChangeTotals);
    });

    test('_formatBytes function', () => {
      const table = {
        '64': '+64 B',
        '1023': '+1023 B',
        '1024': '+1 KiB',
        '4096': '+4 KiB',
        '1073741824': '+1 GiB',
        '-64': '-64 B',
        '-1023': '-1023 B',
        '-1024': '-1 KiB',
        '-4096': '-4 KiB',
        '-1073741824': '-1 GiB',
        '0': '+/-0 B',
      };

      for (const bytes in table) {
        if (table.hasOwnProperty(bytes)) {
          assert.equal(element._formatBytes(bytes), table[bytes]);
        }
      }
    });

    test('_formatPercentage function', () => {
      const table = [
        {size: 100,
          delta: 100,
          display: '',
        },
        {size: 195060,
          delta: 64,
          display: '(+0%)',
        },
        {size: 195060,
          delta: -64,
          display: '(-0%)',
        },
        {size: 394892,
          delta: -7128,
          display: '(-2%)',
        },
        {size: 90,
          delta: -10,
          display: '(-10%)',
        },
        {size: 110,
          delta: 10,
          display: '(+10%)',
        },
      ];

      for (const item of table) {
        assert.equal(element._formatPercentage(
            item.size, item.delta), item.display);
      }
    });

    test('comment filtering', () => {
      element.changeComments._comments = {
        '/COMMIT_MSG': [
          {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
          {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
          {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
        ],
        'myfile.txt': [
          {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
          {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
          {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
        ],
        'unresolved.file': [
          {
            patch_set: 2,
            message: 'wat!?',
            updated: '2017-02-09 16:40:49',
            id: '1',
            unresolved: true,
          },
          {
            patch_set: 2,
            message: 'hi',
            updated: '2017-02-10 16:40:49',
            id: '2',
            in_reply_to: '1',
            unresolved: false,
          },
          {
            patch_set: 2,
            message: 'good news!',
            updated: '2017-02-08 16:40:49',
            id: '3',
            unresolved: true,
          },
        ],
      };
      element.changeComments._drafts = {
        '/COMMIT_MSG': [
          {
            patch_set: 1,
            message: 'hi',
            updated: '2017-02-15 16:40:49',
            id: '5',
            unresolved: true,
          },
          {
            patch_set: 1,
            message: 'fyi',
            updated: '2017-02-15 16:40:49',
            id: '6',
            unresolved: false,
          },
        ],
        'unresolved.file': [
          {
            patch_set: 1,
            message: 'hi',
            updated: '2017-02-11 16:40:49',
            id: '4',
            unresolved: false,
          },
        ],
      };

      const parentTo1 = {
        basePatchNum: 'PARENT',
        patchNum: '1',
      };

      const parentTo2 = {
        basePatchNum: 'PARENT',
        patchNum: '2',
      };

      const _1To2 = {
        basePatchNum: '1',
        patchNum: '2',
      };

      assert.equal(
          element._computeCommentsString(element.changeComments, parentTo1,
              '/COMMIT_MSG', 'comment'), '2 comments (1 unresolved)');
      assert.equal(
          element._computeCommentsString(element.changeComments, _1To2,
              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
      assert.equal(
          element._computeCommentsStringMobile(element.changeComments, parentTo1
              , '/COMMIT_MSG'), '2c');
      assert.equal(
          element._computeCommentsStringMobile(element.changeComments, _1To2
              , '/COMMIT_MSG'), '3c');
      assert.equal(
          element._computeDraftsString(element.changeComments, parentTo1,
              'unresolved.file'), '1 draft');
      assert.equal(
          element._computeDraftsString(element.changeComments, _1To2,
              'unresolved.file'), '1 draft');
      assert.equal(
          element._computeDraftsStringMobile(element.changeComments, parentTo1,
              'unresolved.file'), '1d');
      assert.equal(
          element._computeDraftsStringMobile(element.changeComments, _1To2,
              'unresolved.file'), '1d');
      assert.equal(
          element._computeCommentsString(element.changeComments, parentTo1,
              'myfile.txt', 'comment'), '1 comment');
      assert.equal(
          element._computeCommentsString(element.changeComments, _1To2,
              'myfile.txt', 'comment'), '3 comments');
      assert.equal(
          element._computeCommentsStringMobile(element.changeComments, parentTo1,
              'myfile.txt'), '1c');
      assert.equal(
          element._computeCommentsStringMobile(element.changeComments, _1To2,
              'myfile.txt'), '3c');
      assert.equal(
          element._computeDraftsString(element.changeComments, parentTo1,
              'myfile.txt'), '');
      assert.equal(
          element._computeDraftsString(element.changeComments, _1To2,
              'myfile.txt'), '');
      assert.equal(
          element._computeDraftsStringMobile(element.changeComments, parentTo1,
              'myfile.txt'), '');
      assert.equal(
          element._computeDraftsStringMobile(element.changeComments, _1To2,
              'myfile.txt'), '');
      assert.equal(
          element._computeCommentsString(element.changeComments, parentTo1,
              'file_added_in_rev2.txt', 'comment'), '');
      assert.equal(
          element._computeCommentsString(element.changeComments, _1To2,
              'file_added_in_rev2.txt', 'comment'), '');
      assert.equal(
          element._computeCommentsStringMobile(element.changeComments, parentTo1,
              'file_added_in_rev2.txt'), '');
      assert.equal(
          element._computeCommentsStringMobile(element.changeComments, _1To2,
              'file_added_in_rev2.txt'), '');
      assert.equal(
          element._computeDraftsString(element.changeComments, parentTo1,
              'file_added_in_rev2.txt'), '');
      assert.equal(
          element._computeDraftsString(element.changeComments, _1To2,
              'file_added_in_rev2.txt'), '');
      assert.equal(
          element._computeDraftsStringMobile(element.changeComments, parentTo1,
              'file_added_in_rev2.txt'), '');
      assert.equal(
          element._computeDraftsStringMobile(element.changeComments, _1To2,
              'file_added_in_rev2.txt'), '');
      assert.equal(
          element._computeCommentsString(element.changeComments, parentTo2,
              '/COMMIT_MSG', 'comment'), '1 comment');
      assert.equal(
          element._computeCommentsString(element.changeComments, _1To2,
              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
      assert.equal(
          element._computeCommentsStringMobile(element.changeComments, parentTo2,
              '/COMMIT_MSG'), '1c');
      assert.equal(
          element._computeCommentsStringMobile(element.changeComments, _1To2,
              '/COMMIT_MSG'), '3c');
      assert.equal(
          element._computeDraftsString(element.changeComments, parentTo1,
              '/COMMIT_MSG'), '2 drafts');
      assert.equal(
          element._computeDraftsString(element.changeComments, _1To2,
              '/COMMIT_MSG'), '2 drafts');
      assert.equal(
          element._computeDraftsStringMobile(element.changeComments, parentTo1,
              '/COMMIT_MSG'), '2d');
      assert.equal(
          element._computeDraftsStringMobile(element.changeComments, _1To2,
              '/COMMIT_MSG'), '2d');
      assert.equal(
          element._computeCommentsString(element.changeComments, parentTo2,
              'myfile.txt', 'comment'), '2 comments');
      assert.equal(
          element._computeCommentsString(element.changeComments, _1To2,
              'myfile.txt', 'comment'), '3 comments');
      assert.equal(
          element._computeCommentsStringMobile(element.changeComments, parentTo2,
              'myfile.txt'), '2c');
      assert.equal(
          element._computeCommentsStringMobile(element.changeComments, _1To2,
              'myfile.txt'), '3c');
      assert.equal(
          element._computeDraftsStringMobile(element.changeComments, parentTo2,
              'myfile.txt'), '');
      assert.equal(
          element._computeDraftsStringMobile(element.changeComments, _1To2,
              'myfile.txt'), '');
      assert.equal(
          element._computeCommentsString(element.changeComments, parentTo2,
              'file_added_in_rev2.txt', 'comment'), '');
      assert.equal(
          element._computeCommentsString(element.changeComments, _1To2,
              'file_added_in_rev2.txt', 'comment'), '');
      assert.equal(
          element._computeCommentsString(element.changeComments, parentTo2,
              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
      assert.equal(
          element._computeCommentsString(element.changeComments, _1To2,
              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
    });

    test('_reviewedTitle', () => {
      assert.equal(
          element._reviewedTitle(true), 'Mark as not reviewed (shortcut: r)');

      assert.equal(
          element._reviewedTitle(false), 'Mark as reviewed (shortcut: r)');
    });

    suite('keyboard shortcuts', () => {
      setup(() => {
        element._filesByPath = {
          '/COMMIT_MSG': {},
          'file_added_in_rev2.txt': {},
          'myfile.txt': {},
        };
        element.changeNum = '42';
        element.patchRange = {
          basePatchNum: 'PARENT',
          patchNum: '2',
        };
        element.change = {_number: 42};
        element.$.fileCursor.setCursorAtIndex(0);
      });

      test('toggle left diff via shortcut', () => {
        const toggleLeftDiffStub = sandbox.stub();
        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
        // https://github.com/sinonjs/sinon/issues/781
        const diffsStub = sinon.stub(element, 'diffs', {
          get() {
            return [{toggleLeftDiff: toggleLeftDiffStub}];
          },
        });
        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
        assert.isTrue(toggleLeftDiffStub.calledOnce);
        diffsStub.restore();
      });

      test('keyboard shortcuts', () => {
        flushAsynchronousOperations();

        const items = Polymer.dom(element.root).querySelectorAll('.file-row');
        element.$.fileCursor.stops = items;
        element.$.fileCursor.setCursorAtIndex(0);
        assert.equal(items.length, 3);
        assert.isTrue(items[0].classList.contains('selected'));
        assert.isFalse(items[1].classList.contains('selected'));
        assert.isFalse(items[2].classList.contains('selected'));
        // j with a modifier should not move the cursor.
        MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
        assert.equal(element.$.fileCursor.index, 0);
        // down should not move the cursor.
        MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
        assert.equal(element.$.fileCursor.index, 0);

        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
        assert.equal(element.$.fileCursor.index, 1);
        assert.equal(element.selectedIndex, 1);
        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');

        const navStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
        assert.equal(element.$.fileCursor.index, 2);
        assert.equal(element.selectedIndex, 2);

        // k with a modifier should not move the cursor.
        MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
        assert.equal(element.$.fileCursor.index, 2);

        // up should not move the cursor.
        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
        assert.equal(element.$.fileCursor.index, 2);

        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
        assert.equal(element.$.fileCursor.index, 1);
        assert.equal(element.selectedIndex, 1);
        MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');

        assert(navStub.lastCall.calledWith(element.change,
            'file_added_in_rev2.txt', '2'),
        'Should navigate to /c/42/2/file_added_in_rev2.txt');

        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
        assert.equal(element.$.fileCursor.index, 0);
        assert.equal(element.selectedIndex, 0);

        const createCommentInPlaceStub = sandbox.stub(element.$.diffCursor,
            'createCommentInPlace');
        MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
        assert.isTrue(createCommentInPlaceStub.called);
      });

      test('i key shows/hides selected inline diff', () => {
        sandbox.stub(element, '_expandedPathsChanged');
        flushAsynchronousOperations();
        const files = Polymer.dom(element.root).querySelectorAll('.file-row');
        element.$.fileCursor.stops = files;
        element.$.fileCursor.setCursorAtIndex(0);
        MockInteractions.keyUpOn(element, 73, null, 'i');
        flushAsynchronousOperations();
        assert.include(element._expandedFilePaths, element.diffs[0].path);
        MockInteractions.keyUpOn(element, 73, null, 'i');
        flushAsynchronousOperations();
        assert.notInclude(element._expandedFilePaths, element.diffs[0].path);
        element.$.fileCursor.setCursorAtIndex(1);
        MockInteractions.keyUpOn(element, 73, null, 'i');
        flushAsynchronousOperations();
        assert.include(element._expandedFilePaths, element.diffs[1].path);

        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
        flushAsynchronousOperations();
        for (const index in element.diffs) {
          if (!element.diffs.hasOwnProperty(index)) { continue; }
          assert.include(element._expandedFilePaths, element.diffs[index].path);
        }
        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
        flushAsynchronousOperations();
        for (const index in element.diffs) {
          if (!element.diffs.hasOwnProperty(index)) { continue; }
          assert.notInclude(element._expandedFilePaths,
              element.diffs[index].path);
        }
      });

      test('r key toggles reviewed flag', () => {
        const reducer = (accum, file) => file.isReviewed ? ++accum : accum;
        const getNumReviewed = () => element._files.reduce(reducer, 0);
        flushAsynchronousOperations();

        // Default state should be unreviewed.
        assert.equal(getNumReviewed(), 0);

        // Press the review key to toggle it (set the flag).
        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
        flushAsynchronousOperations();
        assert.equal(getNumReviewed(), 1);

        // Press the review key to toggle it (clear the flag).
        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
        assert.equal(getNumReviewed(), 0);
      });

      suite('_handleOpenFile', () => {
        let interact;

        setup(() => {
          sandbox.stub(element, 'shouldSuppressKeyboardShortcut')
              .returns(false);
          sandbox.stub(element, 'modifierPressed').returns(false);
          const openCursorStub = sandbox.stub(element, '_openCursorFile');
          const openSelectedStub = sandbox.stub(element, '_openSelectedFile');
          const expandStub = sandbox.stub(element, '_togglePathExpanded');

          interact = function(opt_payload) {
            openCursorStub.reset();
            openSelectedStub.reset();
            expandStub.reset();

            const e = new CustomEvent('fake-keyboard-event', opt_payload);
            sinon.stub(e, 'preventDefault');
            element._handleOpenFile(e);
            assert.isTrue(e.preventDefault.called);
            const result = {};
            if (openCursorStub.called) {
              result.opened_cursor = true;
            }
            if (openSelectedStub.called) {
              result.opened_selected = true;
            }
            if (expandStub.called) {
              result.expanded = true;
            }
            return result;
          };
        });

        test('open from selected file', () => {
          element._showInlineDiffs = false;
          assert.deepEqual(interact(), {opened_selected: true});
        });

        test('open from diff cursor', () => {
          element._showInlineDiffs = true;
          assert.deepEqual(interact(), {opened_cursor: true});
        });

        test('expand when user prefers', () => {
          element._showInlineDiffs = false;
          assert.deepEqual(interact(), {opened_selected: true});
          element._userPrefs = {};
          assert.deepEqual(interact(), {opened_selected: true});
        });
      });

      test('shift+left/shift+right', () => {
        const moveLeftStub = sandbox.stub(element.$.diffCursor, 'moveLeft');
        const moveRightStub = sandbox.stub(element.$.diffCursor, 'moveRight');

        let noDiffsExpanded = true;
        sandbox.stub(element, '_noDiffsExpanded', () => noDiffsExpanded);

        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
        assert.isFalse(moveLeftStub.called);
        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
        assert.isFalse(moveRightStub.called);

        noDiffsExpanded = false;

        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
        assert.isTrue(moveLeftStub.called);
        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
        assert.isTrue(moveRightStub.called);
      });
    });

    test('computed properties', () => {
      assert.equal(element._computeFileStatus('A'), 'A');
      assert.equal(element._computeFileStatus(undefined), 'M');
      assert.equal(element._computeFileStatus(null), 'M');

      assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
      assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
          'clazz invisible');
    });

    test('file review status', () => {
      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
      element._filesByPath = {
        '/COMMIT_MSG': {},
        'file_added_in_rev2.txt': {},
        'myfile.txt': {},
      };
      element._loggedIn = true;
      element.changeNum = '42';
      element.patchRange = {
        basePatchNum: 'PARENT',
        patchNum: '2',
      };
      element.$.fileCursor.setCursorAtIndex(0);

      flushAsynchronousOperations();
      const fileRows =
          Polymer.dom(element.root).querySelectorAll('.row:not(.header-row)');
      const checkSelector = 'input.reviewed[type="checkbox"]';
      const commitMsg = fileRows[0].querySelector(checkSelector);
      const fileAdded = fileRows[1].querySelector(checkSelector);
      const myFile = fileRows[2].querySelector(checkSelector);

      assert.isTrue(commitMsg.checked);
      assert.isFalse(fileAdded.checked);
      assert.isTrue(myFile.checked);

      const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
      const markReviewLabel = commitMsg.nextElementSibling;
      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');

      const clickSpy = sandbox.spy(element, '_handleFileListClick');
      MockInteractions.tap(markReviewLabel);
      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
      assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
      assert.equal(markReviewLabel.textContent, 'MARK REVIEWED');
      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);

      MockInteractions.tap(markReviewLabel);
      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
    });

    test('_computeFileStatusLabel', () => {
      assert.equal(element._computeFileStatusLabel('A'), 'Added');
      assert.equal(element._computeFileStatusLabel('M'), 'Modified');
    });

    test('_handleFileListClick', () => {
      element._filesByPath = {
        '/COMMIT_MSG': {},
        'f1.txt': {},
        'f2.txt': {},
      };
      element.changeNum = '42';
      element.patchRange = {
        basePatchNum: 'PARENT',
        patchNum: '2',
      };

      const clickSpy = sandbox.spy(element, '_handleFileListClick');
      const reviewStub = sandbox.stub(element, '_reviewFile');
      const toggleExpandSpy = sandbox.spy(element, '_togglePathExpanded');

      const row = Polymer.dom(element.root)
          .querySelector('.row[data-path="f1.txt"]');

      // Click on the expand button, resulting in _togglePathExpanded being
      // called and not resulting in a call to _reviewFile.
      row.querySelector('div.show-hide').click();
      assert.isTrue(clickSpy.calledOnce);
      assert.isTrue(toggleExpandSpy.calledOnce);
      assert.isFalse(reviewStub.called);

      // Click inside the diff. This should result in no additional calls to
      // _togglePathExpanded or _reviewFile.
      Polymer.dom(element.root).querySelector('gr-diff-host').click();
      assert.isTrue(clickSpy.calledTwice);
      assert.isTrue(toggleExpandSpy.calledOnce);
      assert.isFalse(reviewStub.called);

      // Click the reviewed checkbox, resulting in a call to _reviewFile, but
      // no additional call to _togglePathExpanded.
      row.querySelector('.markReviewed').click();
      assert.isTrue(clickSpy.calledThrice);
      assert.isTrue(toggleExpandSpy.calledOnce);
      assert.isTrue(reviewStub.calledOnce);
    });

    test('_handleFileListClick editMode', () => {
      element._filesByPath = {
        '/COMMIT_MSG': {},
        'f1.txt': {},
        'f2.txt': {},
      };
      element.changeNum = '42';
      element.patchRange = {
        basePatchNum: 'PARENT',
        patchNum: '2',
      };
      element.editMode = true;
      flushAsynchronousOperations();
      const clickSpy = sandbox.spy(element, '_handleFileListClick');
      const toggleExpandSpy = sandbox.spy(element, '_togglePathExpanded');

      // Tap the edit controls. Should be ignored by _handleFileListClick.
      MockInteractions.tap(element.$$('.editFileControls'));
      assert.isTrue(clickSpy.calledOnce);
      assert.isFalse(toggleExpandSpy.called);
    });

    test('patch set from revisions', () => {
      const expected = [
        {num: 4, desc: 'test', sha: 'rev4'},
        {num: 3, desc: 'test', sha: 'rev3'},
        {num: 2, desc: 'test', sha: 'rev2'},
        {num: 1, desc: 'test', sha: 'rev1'},
      ];
      const patchNums = element.computeAllPatchSets({
        revisions: {
          rev3: {_number: 3, description: 'test', date: 3},
          rev1: {_number: 1, description: 'test', date: 1},
          rev4: {_number: 4, description: 'test', date: 4},
          rev2: {_number: 2, description: 'test', date: 2},
        },
      });
      assert.equal(patchNums.length, expected.length);
      for (let i = 0; i < expected.length; i++) {
        assert.deepEqual(patchNums[i], expected[i]);
      }
    });

    test('checkbox shows/hides diff inline', () => {
      element._filesByPath = {
        'myfile.txt': {},
      };
      element.changeNum = '42';
      element.patchRange = {
        basePatchNum: 'PARENT',
        patchNum: '2',
      };
      element.$.fileCursor.setCursorAtIndex(0);
      sandbox.stub(element, '_expandedPathsChanged');
      flushAsynchronousOperations();
      const fileRows =
          Polymer.dom(element.root).querySelectorAll('.row:not(.header-row)');
      // Because the label surrounds the input, the tap event is triggered
      // there first.
      const showHideLabel = fileRows[0].querySelector('label.show-hide');
      const showHideCheck = fileRows[0].querySelector(
          'input.show-hide[type="checkbox"]');
      assert.isNotOk(showHideCheck.checked);
      MockInteractions.tap(showHideLabel);
      assert.isOk(showHideCheck.checked);
      assert.notEqual(element._expandedFilePaths.indexOf('myfile.txt'), -1);
    });

    test('diff mode correctly toggles the diffs', () => {
      element._filesByPath = {
        'myfile.txt': {},
      };
      element.changeNum = '42';
      element.patchRange = {
        basePatchNum: 'PARENT',
        patchNum: '2',
      };
      sandbox.spy(element, '_updateDiffPreferences');
      element.$.fileCursor.setCursorAtIndex(0);
      flushAsynchronousOperations();

      // Tap on a file to generate the diff.
      const row = Polymer.dom(element.root)
          .querySelectorAll('.row:not(.header-row) label.show-hide')[0];

      MockInteractions.tap(row);
      flushAsynchronousOperations();
      const diffDisplay = element.diffs[0];
      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
      element.set('diffViewMode', 'UNIFIED_DIFF');
      assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
      assert.isTrue(element._updateDiffPreferences.called);
    });

    test('expanded attribute not set on path when not expanded', () => {
      element._filesByPath = {
        '/COMMIT_MSG': {},
      };
      assert.isNotOk(element.$$('.expanded'));
    });

    test('tapping row ignores links', () => {
      element._filesByPath = {
        '/COMMIT_MSG': {},
      };
      element.changeNum = '42';
      element.patchRange = {
        basePatchNum: 'PARENT',
        patchNum: '2',
      };
      sandbox.stub(element, '_expandedPathsChanged');
      flushAsynchronousOperations();
      const commitMsgFile = Polymer.dom(element.root)
          .querySelectorAll('.row:not(.header-row) a.pathLink')[0];

      // Remove href attribute so the app doesn't route to a diff view
      commitMsgFile.removeAttribute('href');
      const togglePathSpy = sandbox.spy(element, '_togglePathExpanded');

      MockInteractions.tap(commitMsgFile);
      flushAsynchronousOperations();
      assert(togglePathSpy.notCalled, 'file is opened as diff view');
      assert.isNotOk(element.$$('.expanded'));
      assert.notEqual(getComputedStyle(element.$$('.show-hide')).display,
          'none');
    });

    test('_togglePathExpanded', () => {
      const path = 'path/to/my/file.txt';
      element._filesByPath = {[path]: {}};
      const renderSpy = sandbox.spy(element, '_renderInOrder');
      const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');

      assert.equal(element.$$('iron-icon').icon, 'gr-icons:expand-more');
      assert.equal(element._expandedFilePaths.length, 0);
      element._togglePathExpanded(path);
      flushAsynchronousOperations();
      assert.equal(collapseStub.lastCall.args[0].length, 0);
      assert.equal(element.$$('iron-icon').icon, 'gr-icons:expand-less');

      assert.equal(renderSpy.callCount, 1);
      assert.include(element._expandedFilePaths, path);
      element._togglePathExpanded(path);
      flushAsynchronousOperations();

      assert.equal(element.$$('iron-icon').icon, 'gr-icons:expand-more');
      assert.equal(renderSpy.callCount, 1);
      assert.notInclude(element._expandedFilePaths, path);
      assert.equal(collapseStub.lastCall.args[0].length, 1);
    });

    test('expandAllDiffs and collapseAllDiffs', () => {
      const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
      const cursorUpdateStub = sandbox.stub(element.$.diffCursor,
          'handleDiffUpdate');

      const path = 'path/to/my/file.txt';
      element._filesByPath = {[path]: {}};
      element.expandAllDiffs();
      flushAsynchronousOperations();
      assert.isTrue(element._showInlineDiffs);
      assert.isTrue(cursorUpdateStub.calledOnce);
      assert.equal(collapseStub.lastCall.args[0].length, 0);

      element.collapseAllDiffs();
      flushAsynchronousOperations();
      assert.equal(element._expandedFilePaths.length, 0);
      assert.isFalse(element._showInlineDiffs);
      assert.isTrue(cursorUpdateStub.calledTwice);
      assert.equal(collapseStub.lastCall.args[0].length, 1);
    });

    test('_expandedPathsChanged', done => {
      sandbox.stub(element, '_reviewFile');
      const path = 'path/to/my/file.txt';
      const diffs = [{
        path,
        reload() {
          done();
        },
        cancel() {},
        getCursorStops() { return []; },
        addEventListener(eventName, callback) { callback(new Event(eventName)); },
      }];
      sinon.stub(element, 'diffs', {
        get() { return diffs; },
      });
      element.push('_expandedFilePaths', path);
    });

    test('_clearCollapsedDiffs', () => {
      const diff = {
        cancel: sinon.stub(),
        clearDiffContent: sinon.stub(),
      };
      element._clearCollapsedDiffs([diff]);
      assert.isTrue(diff.cancel.calledOnce);
      assert.isTrue(diff.clearDiffContent.calledOnce);
    });

    test('filesExpanded value updates to correct enum', () => {
      element._filesByPath = {
        'foo.bar': {},
        'baz.bar': {},
      };
      flushAsynchronousOperations();
      assert.equal(element.filesExpanded,
          GrFileListConstants.FilesExpandedState.NONE);
      element.push('_expandedFilePaths', 'baz.bar');
      flushAsynchronousOperations();
      assert.equal(element.filesExpanded,
          GrFileListConstants.FilesExpandedState.SOME);
      element.push('_expandedFilePaths', 'foo.bar');
      flushAsynchronousOperations();
      assert.equal(element.filesExpanded,
          GrFileListConstants.FilesExpandedState.ALL);
      element.collapseAllDiffs();
      flushAsynchronousOperations();
      assert.equal(element.filesExpanded,
          GrFileListConstants.FilesExpandedState.NONE);
      element.expandAllDiffs();
      flushAsynchronousOperations();
      assert.equal(element.filesExpanded,
          GrFileListConstants.FilesExpandedState.ALL);
    });

    test('_renderInOrder', done => {
      const reviewStub = sandbox.stub(element, '_reviewFile');
      let callCount = 0;
      const diffs = [{
        path: 'p0',
        reload() {
          assert.equal(callCount++, 2);
          return Promise.resolve();
        },
      }, {
        path: 'p1',
        reload() {
          assert.equal(callCount++, 1);
          return Promise.resolve();
        },
      }, {
        path: 'p2',
        reload() {
          assert.equal(callCount++, 0);
          return Promise.resolve();
        },
      }];
      element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
          .then(() => {
            assert.isFalse(reviewStub.called);
            assert.isTrue(loadCommentSpy.called);
            done();
          });
    });

    test('_renderInOrder logged in', done => {
      element._loggedIn = true;
      const reviewStub = sandbox.stub(element, '_reviewFile');
      let callCount = 0;
      const diffs = [{
        path: 'p0',
        reload() {
          assert.equal(reviewStub.callCount, 2);
          assert.equal(callCount++, 2);
          return Promise.resolve();
        },
      }, {
        path: 'p1',
        reload() {
          assert.equal(reviewStub.callCount, 1);
          assert.equal(callCount++, 1);
          return Promise.resolve();
        },
      }, {
        path: 'p2',
        reload() {
          assert.equal(reviewStub.callCount, 0);
          assert.equal(callCount++, 0);
          return Promise.resolve();
        },
      }];
      element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
          .then(() => {
            assert.equal(reviewStub.callCount, 3);
            done();
          });
    });

    test('_renderInOrder respects diffPrefs.manual_review', () => {
      element._loggedIn = true;
      element.diffPrefs = {manual_review: true};
      const reviewStub = sandbox.stub(element, '_reviewFile');
      const diffs = [{
        path: 'p',
        reload() { return Promise.resolve(); },
      }];

      return element._renderInOrder(['p'], diffs, 1).then(() => {
        assert.isFalse(reviewStub.called);
        delete element.diffPrefs.manual_review;
        return element._renderInOrder(['p'], diffs, 1).then(() => {
          assert.isTrue(reviewStub.called);
          assert.isTrue(reviewStub.calledWithExactly('p', true));
        });
      });
    });

    test('_loadingChanged fired from reload in debouncer', done => {
      sandbox.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
      element.changeNum = 123;
      element.patchRange = {patchNum: 12};
      element._filesByPath = {'foo.bar': {}};

      element.reload().then(() => {
        assert.isFalse(element._loading);
        element.flushDebouncer('loading-change');
        assert.isFalse(element.classList.contains('loading'));
        done();
      });
      assert.isTrue(element._loading);
      assert.isFalse(element.classList.contains('loading'));
      element.flushDebouncer('loading-change');
      assert.isTrue(element.classList.contains('loading'));
    });

    test('_loadingChanged does not set class when there are no files', () => {
      sandbox.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
      element.changeNum = 123;
      element.patchRange = {patchNum: 12};
      element.reload();
      assert.isTrue(element._loading);
      element.flushDebouncer('loading-change');
      assert.isFalse(element.classList.contains('loading'));
    });

    suite('size bars', () => {
      test('_computeSizeBarLayout', () => {
        assert.isUndefined(element._computeSizeBarLayout(null));
        assert.isUndefined(element._computeSizeBarLayout({}));
        assert.deepEqual(element._computeSizeBarLayout({base: []}), {
          maxInserted: 0,
          maxDeleted: 0,
          maxAdditionWidth: 0,
          maxDeletionWidth: 0,
          deletionOffset: 0,
        });

        const files = [
          {__path: '/COMMIT_MSG', lines_inserted: 10000},
          {__path: 'foo', lines_inserted: 4, lines_deleted: 10},
          {__path: 'bar', lines_inserted: 5, lines_deleted: 8},
        ];
        const layout = element._computeSizeBarLayout({base: files});
        assert.equal(layout.maxInserted, 5);
        assert.equal(layout.maxDeleted, 10);
      });

      test('_computeBarAdditionWidth', () => {
        const file = {
          __path: 'foo/bar.baz',
          lines_inserted: 5,
          lines_deleted: 0,
        };
        const stats = {
          maxInserted: 10,
          maxDeleted: 0,
          maxAdditionWidth: 60,
          maxDeletionWidth: 0,
          deletionOffset: 60,
        };

        // Uses half the space when file is half the largest addition and there
        // are no deletions.
        assert.equal(element._computeBarAdditionWidth(file, stats), 30);

        // If there are no insetions, there is no width.
        stats.maxInserted = 0;
        assert.equal(element._computeBarAdditionWidth(file, stats), 0);

        // If the insertions is not present on the file, there is no width.
        stats.maxInserted = 10;
        file.lines_inserted = undefined;
        assert.equal(element._computeBarAdditionWidth(file, stats), 0);

        // If the file is a commit message, returns zero.
        file.lines_inserted = 5;
        file.__path = '/COMMIT_MSG';
        assert.equal(element._computeBarAdditionWidth(file, stats), 0);

        // Width bottoms-out at the minimum width.
        file.__path = 'stuff.txt';
        file.lines_inserted = 1;
        stats.maxInserted = 1000000;
        assert.equal(element._computeBarAdditionWidth(file, stats), 1.5);
      });

      test('_computeBarAdditionX', () => {
        const file = {
          __path: 'foo/bar.baz',
          lines_inserted: 5,
          lines_deleted: 0,
        };
        const stats = {
          maxInserted: 10,
          maxDeleted: 0,
          maxAdditionWidth: 60,
          maxDeletionWidth: 0,
          deletionOffset: 60,
        };
        assert.equal(element._computeBarAdditionX(file, stats), 30);
      });

      test('_computeBarDeletionWidth', () => {
        const file = {
          __path: 'foo/bar.baz',
          lines_inserted: 0,
          lines_deleted: 5,
        };
        const stats = {
          maxInserted: 10,
          maxDeleted: 10,
          maxAdditionWidth: 30,
          maxDeletionWidth: 30,
          deletionOffset: 31,
        };

        // Uses a quarter the space when file is half the largest deletions and
        // there are equal additions.
        assert.equal(element._computeBarDeletionWidth(file, stats), 15);

        // If there are no deletions, there is no width.
        stats.maxDeleted = 0;
        assert.equal(element._computeBarDeletionWidth(file, stats), 0);

        // If the deletions is not present on the file, there is no width.
        stats.maxDeleted = 10;
        file.lines_deleted = undefined;
        assert.equal(element._computeBarDeletionWidth(file, stats), 0);

        // If the file is a commit message, returns zero.
        file.lines_deleted = 5;
        file.__path = '/COMMIT_MSG';
        assert.equal(element._computeBarDeletionWidth(file, stats), 0);

        // Width bottoms-out at the minimum width.
        file.__path = 'stuff.txt';
        file.lines_deleted = 1;
        stats.maxDeleted = 1000000;
        assert.equal(element._computeBarDeletionWidth(file, stats), 1.5);
      });

      test('_computeSizeBarsClass', () => {
        assert.equal(element._computeSizeBarsClass(false, 'foo/bar.baz'),
            'sizeBars desktop hide');
        assert.equal(element._computeSizeBarsClass(true, '/COMMIT_MSG'),
            'sizeBars desktop invisible');
        assert.equal(element._computeSizeBarsClass(true, 'foo/bar.baz'),
            'sizeBars desktop ');
      });
    });
  });

  suite('gr-file-list inline diff tests', () => {
    let element;
    let sandbox;

    const commitMsgComments = [
      {
        patch_set: 2,
        id: 'ecf0b9fa_fe1a5f62',
        line: 20,
        updated: '2018-02-08 18:49:18.000000000',
        message: 'another comment',
        unresolved: true,
      },
      {
        patch_set: 2,
        id: '503008e2_0ab203ee',
        line: 10,
        updated: '2018-02-14 22:07:43.000000000',
        message: 'a comment',
        unresolved: true,
      },
      {
        patch_set: 2,
        id: 'cc788d2c_cb1d728c',
        line: 20,
        in_reply_to: 'ecf0b9fa_fe1a5f62',
        updated: '2018-02-13 22:07:43.000000000',
        message: 'response',
        unresolved: true,
      },
    ];

    const setupDiff = function(diff) {
      const mock = document.createElement('mock-diff-response');
      diff.comments = {
        left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
        right: [],
        meta: {
          changeNum: 1,
          patchRange: {
            basePatchNum: 'PARENT',
            patchNum: 2,
          },
        },
      };
      diff.prefs = {
        context: 10,
        tab_size: 8,
        font_size: 12,
        line_length: 100,
        cursor_blink_rate: 0,
        line_wrapping: false,
        intraline_difference: true,
        show_line_endings: true,
        show_tabs: true,
        show_whitespace_errors: true,
        syntax_highlighting: true,
        auto_hide_diff_table_header: true,
        theme: 'DEFAULT',
        ignore_whitespace: 'IGNORE_NONE',
      };
      diff.diff = mock.diffResponse;
      diff.$.diff.flushDebouncer('renderDiffTable');
    };

    const renderAndGetNewDiffs = function(index) {
      const diffs =
          Polymer.dom(element.root).querySelectorAll('gr-diff-host');

      for (let i = index; i < diffs.length; i++) {
        setupDiff(diffs[i]);
      }

      element._updateDiffCursor();
      element.$.diffCursor.handleDiffUpdate();
      return diffs;
    };

    setup(done => {
      sandbox = sinon.sandbox.create();
      stub('gr-rest-api-interface', {
        getLoggedIn() { return Promise.resolve(true); },
        getPreferences() { return Promise.resolve({}); },
        getDiffComments() { return Promise.resolve({}); },
        getDiffRobotComments() { return Promise.resolve({}); },
        getDiffDrafts() { return Promise.resolve({}); },
      });
      stub('gr-date-formatter', {
        _loadTimeFormat() { return Promise.resolve(''); },
      });
      stub('gr-diff-host', {
        reload() { return Promise.resolve(); },
      });

      // Element must be wrapped in an element with direct access to the
      // comment API.
      commentApiWrapper = fixture('basic');
      element = commentApiWrapper.$.fileList;
      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
      element.diffPrefs = {};
      sandbox.stub(element, '_reviewFile');

      // Stub methods on the changeComments object after changeComments has
      // been initialized.
      commentApiWrapper.loadComments().then(() => {
        sandbox.stub(element.changeComments, 'getPaths').returns({});
        sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
            .returns({meta: {}, left: [], right: []});
        done();
      });
      element._loading = false;
      element.numFilesShown = 75;
      element.selectedIndex = 0;
      element._filesByPath = {
        '/COMMIT_MSG': {lines_inserted: 9},
        'file_added_in_rev2.txt': {
          lines_inserted: 1,
          lines_deleted: 1,
          size_delta: 10,
          size: 100,
        },
        'myfile.txt': {
          lines_inserted: 1,
          lines_deleted: 1,
          size_delta: 10,
          size: 100,
        },
      };
      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
      element._loggedIn = true;
      element.changeNum = '42';
      element.patchRange = {
        basePatchNum: 'PARENT',
        patchNum: '2',
      };
      sandbox.stub(window, 'fetch', () => {
        return Promise.resolve();
      });
      flushAsynchronousOperations();
    });

    teardown(() => {
      sandbox.restore();
    });

    test('cursor with individually opened files', () => {
      MockInteractions.keyUpOn(element, 73, null, 'i');
      flushAsynchronousOperations();
      let diffs = renderAndGetNewDiffs(0);
      const diffStops = diffs[0].getCursorStops();

      // 1 diff should be rendered.
      assert.equal(diffs.length, 1);

      // No line number is selected.
      assert.isFalse(diffStops[10].classList.contains('target-row'));

      // Tapping content on a line selects the line number.
      MockInteractions.tap(Polymer.dom(
          diffStops[10]).querySelectorAll('.contentText')[0]);
      flushAsynchronousOperations();
      assert.isTrue(diffStops[10].classList.contains('target-row'));

      // Keyboard shortcuts are still moving the file cursor, not the diff
      // cursor.
      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
      flushAsynchronousOperations();
      assert.isTrue(diffStops[10].classList.contains('target-row'));
      assert.isFalse(diffStops[11].classList.contains('target-row'));

      // The file cusor is now at 1.
      assert.equal(element.$.fileCursor.index, 1);
      MockInteractions.keyUpOn(element, 73, null, 'i');
      flushAsynchronousOperations();

      diffs = renderAndGetNewDiffs(1);
      // Two diffs should be rendered.
      assert.equal(diffs.length, 2);
      const diffStopsFirst = diffs[0].getCursorStops();
      const diffStopsSecond = diffs[1].getCursorStops();

      // The line on the first diff is stil selected
      assert.isTrue(diffStopsFirst[10].classList.contains('target-row'));
      assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
    });

    test('cursor with toggle all files', () => {
      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
      flushAsynchronousOperations();

      const diffs = renderAndGetNewDiffs(0);
      const diffStops = diffs[0].getCursorStops();

      // 1 diff should be rendered.
      assert.equal(diffs.length, 3);

      // No line number is selected.
      assert.isFalse(diffStops[10].classList.contains('target-row'));

      // Tapping content on a line selects the line number.
      MockInteractions.tap(Polymer.dom(
          diffStops[10]).querySelectorAll('.contentText')[0]);
      flushAsynchronousOperations();
      assert.isTrue(diffStops[10].classList.contains('target-row'));

      // Keyboard shortcuts are still moving the file cursor, not the diff
      // cursor.
      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
      flushAsynchronousOperations();
      assert.isFalse(diffStops[10].classList.contains('target-row'));
      assert.isTrue(diffStops[11].classList.contains('target-row'));

      // The file cusor is still at 0.
      assert.equal(element.$.fileCursor.index, 0);
    });

    suite('n key presses', () => {
      let nKeySpy;
      let nextCommentStub;
      let nextChunkStub;
      let fileRows;

      setup(() => {
        sandbox.stub(element, '_renderInOrder').returns(Promise.resolve());
        nKeySpy = sandbox.spy(element, '_handleNextChunk');
        nextCommentStub = sandbox.stub(element.$.diffCursor,
            'moveToNextCommentThread');
        nextChunkStub = sandbox.stub(element.$.diffCursor,
            'moveToNextChunk');
        fileRows =
            Polymer.dom(element.root).querySelectorAll('.row:not(.header-row)');
      });

      test('n key with some files expanded and no shift key', () => {
        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
        flushAsynchronousOperations();
        assert.equal(nextChunkStub.callCount, 1);

        // Handle N key should return before calling diff cursor functions.
        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
        assert.isTrue(nKeySpy.called);
        assert.isFalse(nextCommentStub.called);

        // This is also called in diffCursor.moveToFirstChunk.
        assert.equal(nextChunkStub.callCount, 2);
        assert.equal(element.filesExpanded, 'some');
      });

      test('n key with some files expanded and shift key', () => {
        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
        flushAsynchronousOperations();
        assert.equal(nextChunkStub.callCount, 1);

        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
        assert.isTrue(nKeySpy.called);
        assert.isTrue(nextCommentStub.called);

        // This is also called in diffCursor.moveToFirstChunk.
        assert.equal(nextChunkStub.callCount, 1);
        assert.equal(element.filesExpanded, 'some');
      });

      test('n key without all files expanded and shift key', () => {
        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
        flushAsynchronousOperations();

        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
        assert.isTrue(nKeySpy.called);
        assert.isFalse(nextCommentStub.called);

        // This is also called in diffCursor.moveToFirstChunk.
        assert.equal(nextChunkStub.callCount, 2);
        assert.isTrue(element._showInlineDiffs);
      });

      test('n key without all files expanded and no shift key', () => {
        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
        flushAsynchronousOperations();

        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
        assert.isTrue(nKeySpy.called);
        assert.isTrue(nextCommentStub.called);

        // This is also called in diffCursor.moveToFirstChunk.
        assert.equal(nextChunkStub.callCount, 1);
        assert.isTrue(element._showInlineDiffs);
      });
    });

    test('_openSelectedFile behavior', () => {
      const _filesByPath = element._filesByPath;
      element.set('_filesByPath', {});
      const navStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
      // Noop when there are no files.
      element._openSelectedFile();
      assert.isFalse(navStub.called);

      element.set('_filesByPath', _filesByPath);
      flushAsynchronousOperations();
      // Navigates when a file is selected.
      element._openSelectedFile();
      assert.isTrue(navStub.called);
    });

    test('_displayLine', () => {
      sandbox.stub(element, 'shouldSuppressKeyboardShortcut', () => false);
      sandbox.stub(element, 'modifierPressed', () => false);
      element._showInlineDiffs = true;
      const mockEvent = {preventDefault() {}};

      element._displayLine = false;
      element._handleCursorNext(mockEvent);
      assert.isTrue(element._displayLine);

      element._displayLine = false;
      element._handleCursorPrev(mockEvent);
      assert.isTrue(element._displayLine);

      element._displayLine = true;
      element._handleEscKey(mockEvent);
      assert.isFalse(element._displayLine);
    });

    suite('editMode behavior', () => {
      test('reviewed checkbox', () => {
        element._reviewFile.restore();
        const saveReviewStub = sandbox.stub(element, '_saveReviewedState');

        element.editMode = false;
        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
        assert.isTrue(saveReviewStub.calledOnce);

        element.editMode = true;
        flushAsynchronousOperations();

        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
        assert.isTrue(saveReviewStub.calledOnce);
      });

      test('_getReviewedFiles does not call API', () => {
        const apiSpy = sandbox.spy(element.$.restAPI, 'getReviewedFiles');
        element.editMode = true;
        return element._getReviewedFiles().then(files => {
          assert.equal(files.length, 0);
          assert.isFalse(apiSpy.called);
        });
      });
    });

    test('editing actions', () => {
      // Edit controls are guarded behind a dom-if initially and not rendered.
      assert.isNotOk(Polymer.dom(element.root)
          .querySelector('gr-edit-file-controls'));

      element.editMode = true;
      flushAsynchronousOperations();

      // Commit message should not have edit controls.
      const editControls =
          Array.from(
              Polymer.dom(element.root)
                  .querySelectorAll('.row:not(.header-row)'))
              .map(row => row.querySelector('gr-edit-file-controls'));
      assert.isTrue(editControls[0].classList.contains('invisible'));
    });

    test('reloadCommentsForThreadWithRootId', () => {
      // Expand the commit message diff
      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
      const diffs = renderAndGetNewDiffs(0);
      flushAsynchronousOperations();

      // Two comment threads should be generated by renderAndGetNewDiffs
      const threadEls = diffs[0].getThreadEls();
      assert.equal(threadEls.length, 2);
      const threadElsByRootId = new Map(
          threadEls.map(threadEl => [threadEl.rootId, threadEl]));

      const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
      assert.equal(thread1.comments.length, 1);
      assert.equal(thread1.comments[0].message, 'a comment');
      assert.equal(thread1.comments[0].line, 10);

      const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
      assert.equal(thread2.comments.length, 2);
      assert.isTrue(thread2.comments[0].unresolved);
      assert.equal(thread2.comments[0].message, 'another comment');
      assert.equal(thread2.comments[0].line, 20);

      const commentStub =
          sandbox.stub(element.changeComments, 'getCommentsForThread');
      const commentStubRes1 = [
        {
          patch_set: 2,
          id: '503008e2_0ab203ee',
          line: 20,
          updated: '2018-02-08 18:49:18.000000000',
          message: 'edited text',
          unresolved: false,
        },
      ];
      const commentStubRes2 = [
        {
          patch_set: 2,
          id: 'ecf0b9fa_fe1a5f62',
          line: 20,
          updated: '2018-02-08 18:49:18.000000000',
          message: 'another comment',
          unresolved: true,
        },
        {
          patch_set: 2,
          id: '503008e2_0ab203ee',
          line: 10,
          in_reply_to: 'ecf0b9fa_fe1a5f62',
          updated: '2018-02-14 22:07:43.000000000',
          message: 'response',
          unresolved: true,
        },
        {
          patch_set: 2,
          id: '503008e2_0ab203ef',
          line: 20,
          in_reply_to: '503008e2_0ab203ee',
          updated: '2018-02-15 22:07:43.000000000',
          message: 'a third comment in the thread',
          unresolved: true,
        },
      ];
      commentStub.withArgs('503008e2_0ab203ee').returns(
          commentStubRes1);
      commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
          commentStubRes2);

      // Reload comments from the first comment thread, which should have a
      // an updated message and a toggled resolve state.
      element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
          '/COMMIT_MSG');
      assert.equal(thread1.comments.length, 1);
      assert.isFalse(thread1.comments[0].unresolved);
      assert.equal(thread1.comments[0].message, 'edited text');

      // Reload comments from the second comment thread, which should have a new
      // reply.
      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
          '/COMMIT_MSG');
      assert.equal(thread2.comments.length, 3);

      const commentStubCount = commentStub.callCount;
      const getThreadsSpy = sandbox.spy(diffs[0], 'getThreadEls');

      // Should not be getting threads when the file is not expanded.
      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
          'other/file');
      assert.isFalse(getThreadsSpy.called);
      assert.equal(commentStubCount, commentStub.callCount);

      // Should be query selecting diffs when the file is expanded.
      // Should not be fetching change comments when the rootId is not found
      // to match.
      element.reloadCommentsForThreadWithRootId('acf0b9fa_fe1a5f62',
          '/COMMIT_MSG');
      assert.isTrue(getThreadsSpy.called);
      assert.equal(commentStubCount, commentStub.callCount);
    });
  });
  a11ySuite('basic');
</script>
